經過前幾天的學習,我們已經完成了 RAG 系統的核心模組:文件載入與分割、嵌入模型、向量資料庫,以及透過 Retriever 封裝檢索邏輯。今天,我們要進行一次完整的實戰,把這些元件整合起來,打造一個能檢索公司年報的 AI 問答系統。
透過這個專案,你會看到 RAG 的整個流程如何串起來,並且能快速應用到其他類似的資料檢索場景。
要打造一個能夠針對公司年報進行問答的 AI 系統,核心概念就是 RAG(Retrieval-Augmented Generation)。整體流程可分為兩個主要階段:
知識準備階段
在這個階段,我們的目標是將靜態的公司年報轉換為可檢索的知識庫。主要步驟包括:
問答階段
當知識庫準備完成後,系統即可支援使用者進行互動式問答。流程如下:
整體流程如下圖所示:
在這個架構中,Retriever 不再是流程中被動執行的固定步驟,而是被設計成 LLM 可隨時調用的「外部工具」。當使用者提出問題時,LLM 會先進行推理判斷:
這樣的設計讓 LLM 的運作方式更接近 Agent 思維:能根據實際情境自主決定是否使用工具,而不是僅僅依照固定流程執行。
在進入實作之前,我們需要先準備一份公司年報 PDF 檔案。大部分上市公司都會在 投資人關係(Investor Relations, IR) 網站上公開年報,例如台積電(TSMC)就會在官方 IR 頁面提供歷年年報與財務資料。
前往台積電網站進入 投資人關係 頁面,在「公司年報」區塊即可下載最新年度的年報 PDF。
下載後,我們可以將檔案放在專案的指定目錄中。
在 RAG 系統中,我們需要一個能快速進行語意檢索的向量資料庫。市面上常見的選項包括 Chroma、Qdrant、Pinecone、Weaviate、Faiss 等,本篇文章將以 Qdrant 為例,示範如何安裝與整合。
Qdrant 是一個高效能的開源向量資料庫,具備以下特性:
如果是第一次使用,可以透過 Docker 快速在本地啟動 Qdrant:
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
啟動後,Qdrant 預設會在 http://localhost:6333
提供 API 服務,並同時開放內建的 Web UI:
http://localhost:6333/dashboard
在這個介面中,可以檢視 Collections 狀態,確認資料是否正確匯入。
Note:這裡我們以 Qdrant 作為範例,但若在實際專案中使用其他向量資料庫(如 Pinecone 或 Weaviate),流程也大致相同,只需更換
VectorStore
的實作與連線設定即可。
當我們準備好公司財報文件,以及安裝好 Qdrant 向量資料庫環境後,就可以正式開始動手實作一個能夠針對年報進行檢索與回答的智慧問答系統。
在開始之前,請先初始化一個新的專案,命名為 llm-chatbot-with-rag
,我們將在這個專案中完成實作內容。
Note:如果你對 Node.js 專案初始化流程還不熟悉,可以先回顧 Day 01 中「建立 Node.js 專案與 TypeScript 開發環境」的內容。
建立專案環境後,請在專案根目錄中執行以下指令,安裝所需依賴套件:
npm install @langchain/core @langchain/openai @langchain/qdrant @langchain/community pdf-parse langchain dotenv
這些套件的用途如下:
@langchain/core
:LangChain 的核心模組,提供所有開發元件的共通介面與執行邏輯。@langchain/openai
:LangChain 提供的 OpenAI 模型整合套件。@langchain/qdrant
:LangChain 的 Qdrant VectorStore 介面,讓我們能用一致的 API 存取/查詢 Qdrant 向量資料庫。@langchain/community
::LangChain 社群維護的整合模組,包含各種資料來源的 Document Loader。這裡使用 PDFLoader
載入 PDF 檔案。pdf-parse
:供 PDFLoader
使用的底層解析套件。langchain
:LangChain 核心框架,提供 Chain、Tool、Retriever 等功能。dotenv
:用來讀取 .env
檔案中的環境變數,例如 API 金鑰。在專案根目錄建立 .env
檔案,填入必要的設定:
OPENAI_API_KEY=sk-...
QDRANT_URL=
PDF_FILE_PATH=
各變數用途如下:
OPENAI_API_KEY
:OpenAI API 的授權金鑰,用來呼叫 OpenAI 的 Embedding 模型與 Chat 模型。QDRANT_URL
:Qdrant 服務的連線位址,預設本地執行會是 http://localhost:6333
。PDF_FILE_PATH
:指定要匯入的 PDF 文件路徑,本專案以公司年報為例。在程式中,我們會透過 dotenv
套件自動載入 .env
檔案中的內容,確保 API 金鑰與各種設定不會直接寫在程式碼中,也方便未來修改或換環境時直接調整。
為了讓程式碼易於維護與擴充,我們會將不同職責的程式碼拆分成模組化結構,如下所示:
llm-chatbot-with-rag/
├── src/
│ ├── tools/ # 封裝工具模組
│ │ └── annual-report-retriever.tool.ts # 向量檢索封裝成 Tool
│ ├── vectorstores/ # 向量資料庫模組
│ │ └── qdrant.vectorstore.ts # 存取 Qdrant VectorStore
│ ├── index.ts # 程式進入點
│ └── ingest.ts # 一次性資料匯入流程
├── data/ # 公司年報 PDF 檔案放置處
├── package.json # 專案設定與依賴套件清單
├── tsconfig.json # TypeScript 編譯設定
└── .env # 環境變數設定
我們將一次性的資料處理程式 ingest.ts
與主程式 index.ts
的查詢邏輯分開,並透過 tools/
與 vectorstores/
目錄來封裝相關功能。這樣的結構不僅讓專案架構更清晰,也方便日後擴充新的檢索工具,或切換至不同的向量資料庫。
在匯入年報前,請確認已經準備好公司年報的 PDF 檔,並放置 /data
目錄下,並設定好 PDF_FILE_PATH
環境變數路徑。
在 RAG 系統中,第一步就是把外部文件轉換成可檢索的向量資料。這個流程通常只需要執行一次,因此我們將它獨立成 ingest.ts
腳本,專門負責「資料匯入」:
// src/ingest.ts
import 'dotenv/config';
import { OpenAIEmbeddings } from '@langchain/openai';
import { QdrantVectorStore } from '@langchain/qdrant';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
const filePath = process.env.PDF_FILE_PATH as string;
async function ingest() {
const loader = new PDFLoader(filePath);
const rawDocs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const docs = await splitter.splitDocuments(rawDocs);
const embeddings = new OpenAIEmbeddings({
model: 'text-embedding-3-small'
});
await QdrantVectorStore.fromDocuments(docs, embeddings, {
url: process.env.QDRANT_URL,
collectionName: 'annual-report',
});
console.log('done');
}
ingest();
整個流程會先用 PDFLoader
載入年報內容,將 PDF 轉成可處理的純文字文件。接著透過 TextSplitter
把長篇文件切成較小的片段,每段大約 1000
個字元,並設定 200
個字元的重疊區,以避免重要語意在切割時被斷開。
完成分割後,我們再利用 OpenAIEmbeddings
將每個片段轉換成高維度的向量,這樣模型之後才能透過語意相似度進行比對。最後,這些向量會存入 QdrantVectorStore
,並建立一個名為 annual-report
的 collection,專門存放公司年報的知識片段。
為了更方便地執行這個匯入流程,我們可以在 package.json
中加入一個 ingest
script:
{
"scripts": {
"ingest": "ts-node src/ingest.ts"
}
}
這樣只要執行以下命令,就能把 PDF 年報資料匯入 Qdrant:
npm run ingest
完成執行後,你的年報資料就會被妥善儲存在向量資料庫中,後續就能透過檢索功能找到與問題最相關的內容。
在完成資料匯入後,我們需要能夠隨時存取 Qdrant 中的向量資料,並透過它進行檢索。為了避免在專案中重複建立連線或初始化邏輯,可以將這部分抽出成一個共用模組。以下程式碼放在 src/vectorstores/qdrant.vectorstore.ts
,專門負責回傳一個可重複使用的 QdrantVectorStore
實例:
// src/vectorstores/qdrant.vectorstore.ts
import { QdrantVectorStore } from '@langchain/qdrant';
import { OpenAIEmbeddings } from '@langchain/openai';
let vectorStore: QdrantVectorStore | null = null;
export const getVectorStore = async () => {
if (vectorStore) {
return vectorStore;
}
const embeddings = new OpenAIEmbeddings({
model: 'text-embedding-3-small'
});
vectorStore = await QdrantVectorStore.fromExistingCollection(embeddings, {
url: process.env.QDRANT_URL,
collectionName: 'annual-report'
});
return vectorStore;
};
這裡我們採用了簡單的 Singleton 模式,利用 vectorStore
變數來暫存實例,確保在應用程式的生命週期中,只會建立一次向量資料庫連線,避免重複初始化造成效能浪費。
需要特別注意的是,與匯入資料流程中使用的 fromDocuments
不同,這裡改用 fromExistingCollection
,表示直接讀取已經存在的 collection,而不是重新建立並覆蓋資料。這樣能確保我們讀取的正是之前匯入的年報向量資料。
最終,getVectorStore
函式會回傳一個可直接使用的 QdrantVectorStore
實例,方便在 Retriever 或 Tool 中調用,完成語意檢索的工作。
到目前為止,我們已經能透過 QdrantVectorStore
存取公司年報的向量資料。不過,若要讓 LLM 在對話過程中能夠「自行判斷」什麼時候需要檢索,就必須再進一步將這個檢索能力封裝成 Tool。如此一來,使用者只要提問,模型就能決定是否要查詢年報,並根據檢索到的內容生成更精準的回答。
以下程式碼放在 src/tools/retriever.tool.ts
,用來建立一個專門針對公司年報的檢索工具:
// src/tools/retriever.tool.ts
import { createRetrieverTool } from 'langchain/tools/retriever';
import { getVectorStore } from '../vectorstores/qdrant.vectorstore';
export const getRetrieverTool = async () => {
const vectorStore = await getVectorStore();
const retriever = vectorStore.asRetriever(3);
return createRetrieverTool(retriever, {
name: "annual-report-retriever",
description: "檢索公司年報內容,提供與查詢問題最相關的內容片段",
});
};
在這段程式中,我們先呼叫 getVectorStore()
取得 QdrantVectorStore
物件,接著用 .asRetriever(3)
將其轉換成 Retriever
,並設定每次檢索最多回傳 3 個相關片段。這個數字可以依需求調整,以控制上下文的豐富程度。
最後,透過 createRetrieverTool()
將 Retriever 包裝成 Tool,並賦予清楚的 name
和 description
。這些資訊會提供給 LLM,幫助模型理解工具的用途,並在需要時自動呼叫它。
一旦在主程式中將這個 Tool 綁定到 LLM,模型就能在與使用者互動時,自主判斷是否需要啟用 annual-report-retriever
來查詢公司年報,並根據檢索到的片段生成回應。
上面我們已經完成了檢索工具的封裝,接下來要把它整合到對話流程中。主程式的角色,就是將 LLM 與 檢索工具 綁定,並透過命令列(CLI)與使用者互動:
// src/index.ts
import 'dotenv/config';
import readline from 'readline';
import { ChatOpenAI } from '@langchain/openai';
import { BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
import { getRetrieverTool } from './tools/retriever.tool';
async function main() {
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
});
const retrievalTool = await getRetrieverTool();
const llmWithTools = llm.bindTools([retrievalTool]);
const messages: BaseMessage[] = [
new SystemMessage('你是一個樂於助人的 AI 助理。'),
];
const toolsByName: Record<string, any> = {
[retrievalTool.name]: retrievalTool,
};
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('LLM Chatbot 已啟動,輸入訊息開始對話(按 Ctrl+C 離開)。\n');
rl.setPrompt('> ');
rl.prompt();
rl.on('line', async (input) => {
try {
const humanMessage = new HumanMessage(input);
messages.push(humanMessage);
const aiMessage = await llmWithTools.invoke(messages);
messages.push(aiMessage);
const toolCalls = aiMessage.tool_calls || [];
if (toolCalls.length) {
for (const toolCall of toolCalls) {
const selectedTool = toolsByName[toolCall.name];
const toolMessage = await selectedTool.invoke(toolCall);
messages.push(toolMessage);
}
const followup = await llmWithTools.invoke(messages);
messages.push(followup);
}
const lastMessage = messages.slice(-1)[0];
console.log(`${lastMessage.content}\n`);
} catch (err) {
console.error(err);
}
rl.prompt();
});
}
main();
程式啟動後,會建立一個 ChatOpenAI
模型實例,並把 annual-report-retriever
工具綁定進去。當使用者輸入問題時,模型會先生成回應,若判斷需要額外資訊,就會觸發工具呼叫,檢索公司年報中的相關內容。工具返回的結果會再加入對話脈絡,讓模型生成更完整的最終答案。
這樣的設計讓系統具備「動態檢索」能力:模型不僅能回答一般問題,還能在需要時自動查詢年報,並將結果結合到回應中。最終,使用者只要透過 CLI 提問,就能即時獲得來自公司年報的智慧問答服務。
當所有程式碼都完成後,就可以啟動專案來驗證整個問答流程是否正常運作。在此之前,請先確認已經執行過 資料匯入流程,並且 Qdrant 服務已啟動,否則檢索功能將無法正常運作。
由於我們已在 package.json
中設定了 dev
指令,在開發階段可以直接使用以下命令啟動:
npm run dev
啟動後,終端機會顯示提示字元 >
,代表聊天機器人已經就緒。此時你可以輸入任何與公司年報相關的問題,例如:
> 2024 年公司的營收是多少?
2024 年公司的營收為新台幣 2,894,307,699 仟元。
模型會根據問題自動判斷是否需要檢索資料,若需要就會呼叫我們封裝好的 annual-report-retriever
工具,從向量資料庫中找到最相關的內容,並結合檢索結果生成完整的回答。
這樣,你就能直接在 CLI 中與 AI 助手互動,並獲得基於公司年報的智慧問答服務。
今天我們完成了一個完整的 RAG 實戰應用,把前幾天學到的模組整合起來,實作出能針對公司年報進行問答的 AI 系統:
PDFLoader
載入公司年報,並透過 TextSplitter
切分成適合檢索的片段。OpenAIEmbeddings
生成語意向量,並存入 Qdrant 向量資料庫。createRetrieverTool()
將檢索能力封裝成工具,讓 LLM 能自動判斷並呼叫。這個專案展示了 RAG 流程從資料準備到問答互動的完整串接,未來可以快速套用到其他文件檢索場景,讓 AI 成為真正能讀資料的智慧助手。